Skip to content

fix(imap): confirm retired-UID tail via message count before flagging - #341

Open
odiechoo wants to merge 2 commits into
rustmailer:mainfrom
odiechoo:fix/retired-uid-tail
Open

fix(imap): confirm retired-UID tail via message count before flagging#341
odiechoo wants to merge 2 commits into
rustmailer:mainfrom
odiechoo:fix/retired-uid-tail

Conversation

@odiechoo

Copy link
Copy Markdown

Summary

The empty-enumeration guard added in 2.0.1 (#340) produces a persistent false positive on Gmail (and other servers that retire UIDs). When a folder's highest surviving UID sits below UIDNEXT - 1 — which happens routinely on Gmail whenever messages are relabeled, archived, or moved out of a folder — every sync logs a "Refusing to advance highest_uid" warning that never clears, and highest_uid stays pinned below UIDNEXT indefinitely.

Mailbox 'Work': UID FETCH <start>:* returned no UIDs but server UIDNEXT=<n> (<k> messages in range). Refusing to advance highest_uid to avoid skipping them; the next sync will retry.

The range is genuinely empty (the UIDs were retired, not withheld), so there is nothing for the retry to recover — the warning recurs forever.

Root cause

empty_enumeration_anomaly (crates/core/src/imap/executor.rs) decides purely on UIDNEXT > start_uid. That assumes a UID gap between highest_uid and UIDNEXT implies undelivered messages. On Gmail this is false: UIDNEXT only ever increases, and relabeling / archiving / moving a message retires its UID in that folder without lowering UIDNEXT. So a folder whose last surviving message is UID 337 can sit at UIDNEXT=344 with 338–343 being retired (empty) slots. UID FETCH 338:* correctly returns nothing, but the guard reads that as "6 messages I'm refusing to skip" and re-flags every sync.

The ({} messages in range) figure is UIDNEXT - start_uid, i.e. a slot count, not a confirmed message count — for a retired tail the real count is 0.

Reproduction

Gmail account synced folder-by-folder (individual labels).
Remove the label from (or archive/move) the message(s) holding the top UIDs of a folder, so the highest surviving UID is below UIDNEXT - 1.
No new mail arrives in that folder.
Every sync logs the "Refusing to advance highest_uid" warning for that folder; it never clears (only real new mail, which pushes a non-empty {start}:*, would).

Impact

No data loss — the archive is append-only and complete for these folders; the tail is genuinely empty. But it produces permanent per-folder error spam and pins highest_uid, which is indistinguishable at a glance from a real truncation/loss anomaly. Users cannot tell benign retired tails from a genuine problem.

Fix

The guard cannot distinguish a truncated/throttled empty (real mail — must not skip) from a retired-UID tail (benign) from UIDNEXT alone. Confirm with a signal immune to SEARCH/FETCH truncation: the message count. The EXAMINE response already carries exists (server count), and the local stored count is equally authoritative.

When the enumeration is empty and the guard would fire:

If examined.exists <= local_stored_count → the folder holds no more mail than we already store → retired tail → advance highest_uid to UIDNEXT - 1.
Else → server genuinely has more than we hold → real gap → keep the current refuse-and-retry behavior.

This preserves the anti-truncation protection while eliminating the false positive. It's conservative on the oversized-skipped edge: if a folder has messages skipped for max_email_size_bytes, exists can exceed the local count and the guard simply falls through to the retry path — never skipping mail.

Changes: adds a cheap IndexManager::count_for_mailbox (Tantivy Count collector, no ID materialization), and a resolve_empty_enumeration helper called from both fetch_new_mail_range and fetch_new_mail_with_before.

@cla-assistant

cla-assistant Bot commented Aug 10, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant